# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Vulnerability, feature_category: :vulnerability_management do
  let(:state_values) { { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 } }
  let(:severity_values) { { info: 1, unknown: 2, low: 4, medium: 5, high: 6, critical: 7 } }

  let(:confidence_values) do
    { ignore: 1, unknown: 2, experimental: 3, low: 4, medium: 5, high: 6, confirmed: 7 }
  end

  let(:report_types) do
    {
      sast: 0,
      dependency_scanning: 1,
      container_scanning: 2,
      dast: 3,
      secret_detection: 4,
      coverage_fuzzing: 5,
      api_fuzzing: 6,
      cluster_image_scanning: 7,
      generic: 99
    }
  end

  let_it_be(:project) { create(:project) }
  let_it_be(:user) { create(:user) }
  let_it_be(:vulnerability) { create(:vulnerability, :sast, :confirmed, :low, project: project) }
  let_it_be(:finding) { create(:vulnerabilities_finding, vulnerability: vulnerability) }

  it { is_expected.to have_locked_schema('b389b0f5a5b631e0d984c77ac8998c76a92ab2475d6ac8a7cac66182160f81c9').reference('https://gitlab.com/gitlab-org/gitlab/-/issues/349315') }

  describe 'enums' do
    it { is_expected.to define_enum_for(:state).with_values(state_values) }
    it { is_expected.to define_enum_for(:severity).with_values(severity_values).with_prefix(:severity) }
    it { is_expected.to define_enum_for(:confidence).with_values(confidence_values).with_prefix(:confidence) }
    it { is_expected.to define_enum_for(:report_type).with_values(report_types) }

    it_behaves_like 'having unique enum values'
  end

  describe 'associations' do
    subject { build(:vulnerability) }

    it { is_expected.to belong_to(:project) }
    it { is_expected.to belong_to(:milestone) }
    it { is_expected.to belong_to(:epic) }
    it { is_expected.to have_many(:findings).class_name('Vulnerabilities::Finding').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:dismissed_findings).class_name('Vulnerabilities::Finding').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:merge_request_links).class_name('Vulnerabilities::MergeRequestLink').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:merge_requests).through(:merge_request_links) }
    it { is_expected.to have_many(:external_issue_links).class_name('Vulnerabilities::ExternalIssueLink').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:issue_links).class_name('Vulnerabilities::IssueLink').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:related_issues).through(:issue_links).source(:issue) }
    it { is_expected.to have_many(:state_transitions).class_name('Vulnerabilities::StateTransition').inverse_of(:vulnerability) }
    it { is_expected.to belong_to(:author).class_name('User') }
    it { is_expected.to belong_to(:updated_by).class_name('User') }
    it { is_expected.to belong_to(:last_edited_by).class_name('User') }
    it { is_expected.to belong_to(:resolved_by).class_name('User') }
    it { is_expected.to belong_to(:dismissed_by).class_name('User') }
    it { is_expected.to belong_to(:confirmed_by).class_name('User') }

    it { is_expected.to have_one(:group).through(:project) }
    it { is_expected.to have_one(:vulnerability_read) }

    it { is_expected.to have_many(:findings).class_name('Vulnerabilities::Finding').dependent(false) }
    it { is_expected.to have_many(:notes).dependent(:delete_all) }
    it { is_expected.to have_many(:user_mentions).class_name('VulnerabilityUserMention') }
    it { is_expected.to have_many(:state_transitions).class_name('Vulnerabilities::StateTransition') }
  end

  describe 'validations' do
    subject { build(:vulnerability) }

    it { is_expected.to validate_presence_of(:project) }
    it { is_expected.to validate_presence_of(:author) }
    it { is_expected.to validate_presence_of(:title) }
    it { is_expected.to validate_presence_of(:severity) }
    it { is_expected.to validate_presence_of(:report_type) }

    it { is_expected.to validate_length_of(:title).is_at_most(::Issuable::TITLE_LENGTH_MAX) }
    it { is_expected.to validate_length_of(:title_html).is_at_most(::Issuable::TITLE_HTML_LENGTH_MAX) }
    it { is_expected.to validate_length_of(:description).is_at_most(::Issuable::DESCRIPTION_LENGTH_MAX).allow_nil }
    it { is_expected.to validate_length_of(:description_html).is_at_most(::Issuable::DESCRIPTION_HTML_LENGTH_MAX).allow_nil }
  end

  describe 'text fields' do
    let_it_be(:vulnerability) { create(:vulnerability, title: '_My title_ ', description: '**Hello `world`**') }

    describe '#title_html' do
      let(:expected_title) { '_My title_' } # no markdown rendering because it's a single line field

      subject { vulnerability.title_html }

      it { is_expected.to eq(expected_title) }
    end

    describe '#description_html' do
      let(:expected_description) { '<p data-sourcepos="1:1-1:17" dir="auto"><strong>Hello <code>world</code></strong></p>' }

      subject { vulnerability.description_html }

      it { is_expected.to eq(expected_description) }
    end

    describe 'redactable fields' do
      before do
        stub_commonmark_sourcepos_disabled
      end

      it_behaves_like 'model with redactable field' do
        let(:model) { build(:vulnerability) }
        let(:field) { :description }
      end
    end
  end

  describe '.visible_to_user_and_access_level' do
    let(:project_2) { create(:project) }

    before do
      project.add_developer(user)

      create(:vulnerability, project: project_2)
    end

    subject { described_class.visible_to_user_and_access_level(user, ::Gitlab::Access::DEVELOPER) }

    it 'returns vulnerabilities visible for given user with provided access level' do
      is_expected.to contain_exactly(vulnerability)
    end
  end

  describe '.with_limit' do
    subject(:limited_vulnerabilities) { described_class.with_limit(1) }

    before do
      # There is already a vulnerability created before
      # so this will make the total of 2 vulnerabilities.
      create(:vulnerability, project: project)
    end

    it 'returns vulnerabilities limited by provided value' do
      expect(limited_vulnerabilities.count).to eq(1)
    end
  end

  describe '.autocomplete_search' do
    using RSpec::Parameterized::TableSyntax

    let_it_be(:vulnerability_1) { create(:vulnerability, title: 'Predictable pseudorandom number generator') }
    let_it_be(:vulnerability_2) { create(:vulnerability, title: 'Use of pseudorandom MD2, MD4, or MD5 hash function.') }

    subject { described_class.autocomplete_search(search) }

    where(:search, :filtered_vulnerabilities) do
      'PSEUDORANDOM'             | [:vulnerability_1, :vulnerability_2]
      'Predictable PSEUDORANDOM' | [:vulnerability_1]
      'mD2'                      | [:vulnerability_2]
    end

    with_them do
      it 'returns the vulnerabilities filtered' do
        expect(subject).to match_array(filtered_vulnerabilities.map { |name| public_send(name) })
      end
    end

    context 'when id is used in search params' do
      let(:search) { vulnerability_1.id.to_s }

      it { is_expected.to match_array([vulnerability_1]) }
    end

    context 'when query is empty' do
      let(:search) { '' }

      it { is_expected.to match_array([vulnerability, vulnerability_1, vulnerability_2]) }
    end
  end

  describe '.for_projects' do
    let(:project_2) { create(:project) }

    before do
      create(:vulnerability, project: project_2)
    end

    subject { described_class.for_projects([project.id]) }

    it 'returns vulnerabilities related to the given project IDs' do
      is_expected.to contain_exactly(vulnerability)
    end
  end

  describe '.with_report_types' do
    let!(:dast_vulnerability) { create(:vulnerability, :dast) }
    let!(:dependency_scanning_vulnerability) { create(:vulnerability, :dependency_scanning) }
    let(:sast_vulnerability) { vulnerability }
    let(:report_types) { %w[sast dast] }

    subject { described_class.with_report_types(report_types) }

    it 'returns vulnerabilities matching the given report_types' do
      is_expected.to contain_exactly(sast_vulnerability, dast_vulnerability)
    end
  end

  describe '.with_severities' do
    let!(:high_vulnerability) { create(:vulnerability, :high) }
    let!(:medium_vulnerability) { create(:vulnerability, :medium) }
    let(:low_vulnerability) { vulnerability }
    let(:severities) { %w[medium low] }

    subject { described_class.with_severities(severities) }

    it 'returns vulnerabilities matching the given severities' do
      is_expected.to contain_exactly(medium_vulnerability, low_vulnerability)
    end
  end

  describe '.with_states' do
    let!(:detected_vulnerability) { create(:vulnerability, :detected) }
    let!(:dismissed_vulnerability) { create(:vulnerability, :dismissed) }
    let(:confirmed_vulnerability) { vulnerability }
    let(:states) { %w[detected confirmed] }

    subject { described_class.with_states(states) }

    it 'returns vulnerabilities matching the given states' do
      is_expected.to contain_exactly(detected_vulnerability, confirmed_vulnerability)
    end
  end

  describe '.with_scanner_external_ids' do
    let!(:vulnerability_1) { create(:vulnerability, :with_findings) }
    let!(:vulnerability_2) { create(:vulnerability, :with_findings) }
    let(:vulnerability_3) { vulnerability }
    let(:scanner_external_ids) { [vulnerability_1.finding_scanner_external_id, vulnerability_3.finding_scanner_external_id] }

    subject { described_class.with_scanner_external_ids(scanner_external_ids) }

    it 'returns vulnerabilities matching the given scanner external IDs' do
      is_expected.to contain_exactly(vulnerability_1, vulnerability_3)
    end
  end

  describe '.order_severity_' do
    let_it_be(:low_vulnerability) { vulnerability }
    let_it_be(:critical_vulnerability) { create(:vulnerability, :critical) }
    let_it_be(:medium_vulnerability) { create(:vulnerability, :medium) }

    describe 'ascending' do
      subject { described_class.order_severity_asc }

      it { is_expected.to eq([low_vulnerability, medium_vulnerability, critical_vulnerability]) }
    end

    describe 'descending' do
      subject { described_class.order_severity_desc }

      it { is_expected.to eq([critical_vulnerability, medium_vulnerability, low_vulnerability]) }
    end
  end

  describe '.order_created_at_' do
    let_it_be(:old_vulnerability) { create(:vulnerability, created_at: 2.weeks.ago) }
    let_it_be(:very_old_vulnerability) { vulnerability }
    let_it_be(:fresh_vulnerability) { create(:vulnerability, created_at: 3.days.ago) }

    before(:all) do
      vulnerability.update_column(:created_at, 1.year.ago)
    end

    describe 'ascending' do
      subject { described_class.order_created_at_asc }

      it 'returns vulnerabilities ordered by created_at' do
        is_expected.to eq([very_old_vulnerability, old_vulnerability, fresh_vulnerability])
      end
    end

    describe 'descending' do
      subject { described_class.order_created_at_desc }

      it 'returns vulnerabilities ordered by created_at' do
        is_expected.to eq([fresh_vulnerability, old_vulnerability, very_old_vulnerability])
      end
    end
  end

  describe '.order_id_desc' do
    subject { described_class.order_id_desc }

    before do
      create_list(:vulnerability, 2)
    end

    it 'returns vulnerabilities ordered by id' do
      is_expected.to be_sorted(:id, :desc)
    end
  end

  describe '.with_resolution' do
    let_it_be(:vulnerability_with_resolution) { create(:vulnerability, resolved_on_default_branch: true) }
    let_it_be(:vulnerability_without_resolution) { vulnerability }

    subject { described_class.with_resolution(with_resolution) }

    before(:all) do
      vulnerability.update!(resolved_on_default_branch: false)
    end

    context 'when no argument is provided' do
      subject { described_class.with_resolution }

      it { is_expected.to eq([vulnerability_with_resolution]) }
    end

    context 'when the argument is provided' do
      context 'when the given argument is `true`' do
        let(:with_resolution) { true }

        it { is_expected.to eq([vulnerability_with_resolution]) }
      end

      context 'when the given argument is `false`' do
        let(:with_resolution) { false }

        it { is_expected.to eq([vulnerability_without_resolution]) }
      end
    end
  end

  describe '.with_issues' do
    let_it_be(:vulnerability_with_issues) { create(:vulnerability, :with_issue_links) }
    let_it_be(:vulnerability_without_issues) { vulnerability }

    subject { described_class.with_issues(with_issues) }

    context 'when no argument is provided' do
      subject { described_class.with_issues }

      it { is_expected.to eq([vulnerability_with_issues]) }
    end

    context 'when the argument is provided' do
      context 'when the given argument is `true`' do
        let(:with_issues) { true }

        it { is_expected.to eq([vulnerability_with_issues]) }
      end

      context 'when the given argument is `false`' do
        let(:with_issues) { false }

        it { is_expected.to eq([vulnerability_without_issues]) }
      end
    end
  end

  describe '.order_by' do
    let_it_be(:low_vulnerability) { vulnerability }
    let_it_be(:critical_vulnerability) { create(:vulnerability, :critical) }
    let_it_be(:medium_vulnerability) { create(:vulnerability, :medium) }

    subject { described_class.order_by(method) }

    context 'when ordered by severity_desc' do
      let(:method) { :severity_desc }

      it { is_expected.to eq([critical_vulnerability, medium_vulnerability, low_vulnerability]) }
    end

    context 'when ordered by severity_asc' do
      let(:method) { :severity_asc }

      it { is_expected.to eq([low_vulnerability, medium_vulnerability, critical_vulnerability]) }
    end
  end

  describe '.active_state_values' do
    let(:expected_values) { ::Vulnerability.states.values_at('detected', 'confirmed') }

    subject { described_class.active_state_values }

    it { is_expected.to match_array(expected_values) }
  end

  describe '.grouped_by_severity' do
    before do
      # There is already a vulnerability created with `low`
      # severity therefore we are not creating one with `low` severity here.
      create_list(:vulnerability, 2, :critical)
      create_list(:vulnerability, 1, :high)
      create_list(:vulnerability, 1, :medium)
      create_list(:vulnerability, 1, :info)
      create_list(:vulnerability, 1, :unknown)
    end

    subject { described_class.grouped_by_severity.count }

    it { is_expected.to eq('critical' => 2, 'high' => 1, 'info' => 1, 'low' => 1, 'medium' => 1, 'unknown' => 1) }
  end

  describe '.by_primary_identifier_ids' do
    it 'returns matching vulnerabilities' do
      create(:vulnerability)
      create(
        :vulnerabilities_finding_identifier,
        finding: vulnerability.finding,
        identifier: vulnerability.finding.primary_identifier
      )

      expect(
        described_class.by_primary_identifier_ids(vulnerability.finding.primary_identifier_id)
      ).to match_array([vulnerability])
    end
  end

  describe '.by_project_fingerprints' do
    let_it_be(:vulnerability_1) { vulnerability }
    let_it_be(:vulnerability_2) { create(:vulnerability, :with_findings) }

    let(:expected_vulnerabilities) { [vulnerability_1] }

    subject { described_class.by_project_fingerprints(vulnerability_1.finding.project_fingerprint) }

    it { is_expected.to match_array(expected_vulnerabilities) }
  end

  describe '.by_scanner_ids' do
    it 'returns matching vulnerabilities' do
      vulnerability1 = vulnerability
      create(:vulnerability, :with_findings)

      result = described_class.by_scanner_ids(vulnerability1.finding_scanner_id)

      expect(result).to match_array([vulnerability1])
    end
  end

  describe '.reference_prefix' do
    subject { described_class.reference_prefix }

    it { is_expected.to eq('[vulnerability:') }
  end

  describe '.reference_postfix' do
    subject { described_class.reference_postfix }

    it { is_expected.to eq(']') }
  end

  describe '.reference_pattern' do
    subject { described_class.reference_pattern }

    it { is_expected.to match('[vulnerability:123]') }
    it { is_expected.to match('[vulnerability:gitlab-foss/123]') }
    it { is_expected.to match('[vulnerability:gitlab-org/gitlab-foss/123]') }
  end

  describe '.link_reference_pattern' do
    subject { described_class.link_reference_pattern }

    it { is_expected.to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/-/security/vulnerabilities/123") }
    it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/security/vulnerabilities/123") }
    it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/issues/123") }
    it { is_expected.not_to match("gitlab-org/gitlab-foss/milestones/123") }
  end

  describe '.with_container_image' do
    let_it_be(:vulnerability) { create(:vulnerability, project: project, report_type: 'cluster_image_scanning') }
    let_it_be(:finding) { create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, vulnerability: vulnerability) }
    let_it_be(:image) { finding.location['image'] }

    before do
      finding_with_different_image = create(
        :vulnerabilities_finding,
        :with_cluster_image_scanning_scanning_metadata,
        vulnerability: create(:vulnerability, report_type: 'cluster_image_scanning')
      )
      finding_with_different_image.location['image'] = 'alpine:latest'
      finding_with_different_image.save!
    end

    subject(:cluster_vulnerabilities) { described_class.with_container_image(image) }

    it 'returns vulnerabilities with given image' do
      expect(cluster_vulnerabilities).to contain_exactly(vulnerability)
    end
  end

  describe '.with_cluster_ids' do
    let_it_be(:vulnerability) { create(:vulnerability, project: project, report_type: 'cluster_image_scanning') }
    let_it_be(:cluster_agent) { create(:cluster_agent, project: project) }
    let_it_be(:finding) { create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, agent_id: cluster_agent.id, vulnerability: vulnerability) }
    let_it_be(:cluster_ids) { [finding.location['kubernetes_resource']['cluster_id']] }

    before do
      finding_with_different_cluster_id = create(
        :vulnerabilities_finding,
        :with_cluster_image_scanning_scanning_metadata,
        agent_id: cluster_agent.id.to_s,
        vulnerability: create(:vulnerability, report_type: 'cluster_image_scanning')
      )
      finding_with_different_cluster_id.location['kubernetes_resource']['cluster_id'] = '2'
      finding_with_different_cluster_id.save!

      finding_without_cluster_id = create(
        :vulnerabilities_finding,
        :with_cluster_image_scanning_scanning_metadata,
        agent_id: cluster_agent.id.to_s,
        vulnerability: create(:vulnerability, report_type: 'cluster_image_scanning')
      )
      finding_without_cluster_id.location['kubernetes_resource']['cluster_id'] = nil
      finding_without_cluster_id.save!
    end

    subject(:cluster_vulnerabilities) { described_class.with_cluster_ids(cluster_ids) }

    it 'returns vulnerabilities with given cluster_id' do
      expect(cluster_vulnerabilities).to contain_exactly(vulnerability)
    end
  end

  describe '.with_cluster_agent_ids' do
    let_it_be(:vulnerability) { create(:vulnerability, project: project, report_type: 'cluster_image_scanning') }
    let_it_be(:cluster_agent) { create(:cluster_agent, project: project) }
    let_it_be(:other_cluster_agent) { create(:cluster_agent, project: project) }
    let_it_be(:finding) { create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, agent_id: cluster_agent.id.to_s, vulnerability: vulnerability) }
    let_it_be(:finding_with_different_agent_id) { create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, agent_id: other_cluster_agent.id.to_s, vulnerability: vulnerability) }
    let_it_be(:finding_without_agent_id) { create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, agent_id: nil, vulnerability: vulnerability) }
    let_it_be(:cluster_agent_ids) { [finding.location['kubernetes_resource']['agent_id']] }

    subject(:cluster_agent_vulnerabilities) { described_class.with_cluster_agent_ids(cluster_agent_ids) }

    it 'returns vulnerabilities with given agent_id' do
      expect(cluster_agent_vulnerabilities).to contain_exactly(vulnerability)
    end
  end

  describe 'created_in_time_range' do
    it 'returns vulnerabilities created in given time range', :aggregate_failures do
      record1 = create(:vulnerability, created_at: 1.day.ago)
      record2 = create(:vulnerability, created_at: 1.month.ago)
      record3 = create(:vulnerability, created_at: 1.year.ago)

      expect(described_class.created_in_time_range(from: 1.week.ago)).to match_array([record1, vulnerability])
      expect(described_class.created_in_time_range(to: 1.week.ago)).to match_array([record2, record3])
      expect(described_class.created_in_time_range(from: 2.months.ago, to: 1.week.ago)).to match_array([record2])
    end
  end

  describe '#to_reference' do
    let(:namespace) { build(:namespace) }
    let(:project) { build(:project, namespace: namespace) }
    let(:vulnerability) { build(:vulnerability, id: 1, project: project) }

    context 'when nil argument' do
      it 'returns vulnerability id' do
        expect(vulnerability.to_reference).to eq '[vulnerability:1]'
      end

      it 'returns complete path to the vulnerability with full: true' do
        expect(vulnerability.to_reference(full: true)).to eq "[vulnerability:#{project.full_path}/1]"
      end
    end

    context 'when argument is a project' do
      context 'when same project' do
        it 'returns vulnerability id' do
          expect(vulnerability.to_reference(project)).to eq('[vulnerability:1]')
        end

        it 'returns full reference with full: true' do
          expect(vulnerability.to_reference(project, full: true)).to eq "[vulnerability:#{project.full_path}/1]"
        end
      end

      context 'when cross-project in same namespace' do
        let(:another_project) do
          build(:project, namespace: project.namespace)
        end

        it 'returns a cross-project reference' do
          expect(vulnerability.to_reference(another_project)).to eq "[vulnerability:#{project.path}/1]"
        end

        it 'returns full reference with full: true' do
          expect(vulnerability.to_reference(another_project, full: true)).to eq "[vulnerability:#{project.full_path}/1]"
        end
      end

      context 'when cross-project in different namespace' do
        let(:another_namespace) { build(:namespace, id: non_existing_record_id, path: 'another-namespace') }
        let(:another_namespace_project) { build(:project, namespace: another_namespace) }

        it 'returns complete path to the vulnerability' do
          expect(vulnerability.to_reference(another_namespace_project)).to eq "[vulnerability:#{project.full_path}/1]"
        end

        it 'returns full reference with full: true' do
          expect(vulnerability.to_reference(another_namespace_project, full: true)).to eq "[vulnerability:#{project.full_path}/1]"
        end
      end
    end

    context 'when argument is a namespace' do
      context 'when same as vulnerability' do
        it 'returns path to the vulnerability with the project path' do
          expect(vulnerability.to_reference(namespace)).to eq "[vulnerability:#{project.path}/1]"
        end

        it 'returns full reference with full: true' do
          expect(vulnerability.to_reference(namespace, full: true)).to eq "[vulnerability:#{project.full_path}/1]"
        end
      end

      context 'when different from vulnerability namespace' do
        let(:group) { build(:group, name: 'Group', path: 'sample-group') }

        it 'returns full path to the vulnerability with full: true' do
          expect(vulnerability.to_reference(group)).to eq "[vulnerability:#{project.full_path}/1]"
        end

        it 'returns full path to the vulnerability with full: false' do
          expect(vulnerability.to_reference(group, full: false)).to eq "[vulnerability:#{project.full_path}/1]"
        end
      end
    end
  end

  describe '#finding' do
    let_it_be(:finding_1) { finding }
    let_it_be(:finding_2) { create(:vulnerabilities_finding, vulnerability: vulnerability) }

    subject { vulnerability.finding }

    context 'with multiple findings' do
      it { is_expected.to eq(finding_1) }
    end
  end

  describe 'delegations' do
    it { is_expected.to delegate_method(:scanner_name).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:description).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:description_html).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:metadata).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:cve_value).to(:finding).allow_nil }
    it { is_expected.to delegate_method(:cwe_value).to(:finding).allow_nil }
    it { is_expected.to delegate_method(:other_identifier_values).to(:finding).allow_nil }
    it { is_expected.to delegate_method(:default_branch).to(:project).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:name).to(:project).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:name).to(:group).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:location).to(:finding).allow_nil }
  end

  describe '#resource_parent' do
    subject(:resource_parent) { vulnerability.resource_parent }

    it { is_expected.to eq(project) }
  end

  describe '#discussions_rendered_on_frontend?' do
    subject(:discussions_rendered_on_frontend) { vulnerability.discussions_rendered_on_frontend? }

    it { is_expected.to be true }
  end

  describe '.parent_class' do
    subject(:parent_class) { ::Vulnerability.parent_class }

    it { is_expected.to eq(::Project) }
  end

  describe '.to_ability_name' do
    subject(:ability_name) { ::Vulnerability.to_ability_name }

    it { is_expected.to eq('vulnerability') }
  end

  describe '#note_etag_key' do
    it 'returns a correct etag key' do
      expect(vulnerability.note_etag_key).to eq(
        ::Gitlab::Routing.url_helpers.project_security_vulnerability_notes_path(project, vulnerability)
      )
    end
  end

  describe '#user_notes_count' do
    let(:expected_count) { 10 }
    let(:mock_service_class) { instance_double(Vulnerabilities::UserNotesCountService, count: expected_count) }

    subject(:user_notes_count) { vulnerability.user_notes_count }

    before do
      allow(Vulnerabilities::UserNotesCountService).to receive(:new).with(vulnerability).and_return(mock_service_class)
    end

    it 'delegates the call to Vulnerabilities::UserNotesCountService' do
      expect(user_notes_count).to eq(expected_count)
      expect(mock_service_class).to have_received(:count)
    end
  end

  describe '#after_note_changed' do
    let(:vulnerability) { build(:vulnerability) }
    let(:note) { instance_double(Note, system?: is_system_note?) }
    let(:mock_service_class) { instance_double(Vulnerabilities::UserNotesCountService, delete_cache: true) }

    subject(:after_note_changed) { vulnerability.after_note_changed(note) }

    before do
      allow(Vulnerabilities::UserNotesCountService).to receive(:new).with(vulnerability).and_return(mock_service_class)
    end

    context 'when the changed note is a system note' do
      let(:is_system_note?) { true }

      it 'does not send #delete_cache message to Vulnerabilities::UserNotesCountService' do
        after_note_changed

        expect(mock_service_class).not_to have_received(:delete_cache)
      end
    end

    context 'when the changed note is not a system note' do
      let(:is_system_note?) { false }

      it 'sends #delete_cache message to Vulnerabilities::UserNotesCountService' do
        after_note_changed

        expect(mock_service_class).to have_received(:delete_cache)
      end
    end
  end

  describe '#after_note_created' do
    subject { described_class.instance_method(:after_note_created).original_name }

    it { is_expected.to eq(:after_note_changed) }
  end

  describe '#after_note_destroyed' do
    subject { described_class.instance_method(:after_note_destroyed).original_name }

    it { is_expected.to eq(:after_note_changed) }
  end

  describe '#stat_diff' do
    subject { vulnerability.stat_diff }

    it { is_expected.to be_an_instance_of(Vulnerabilities::StatDiff) }
  end

  describe '#blob_path' do
    let(:finding) { create(:vulnerabilities_finding, :detected, :with_pipeline) }
    let(:vulnerability) { finding.vulnerability }

    subject { vulnerability.blob_path }

    it 'returns project blob path' do
      expect(subject).to eq(
        "/#{vulnerability.project.full_path}/-/blob/#{finding.last_finding_pipeline.sha}/#{vulnerability.finding.file}"
      )
    end

    context 'with a Secret Detection finding' do
      let(:finding) { create(:vulnerabilities_finding, :with_secret_detection, :with_pipeline) }

      it 'uses the commit SHA in the blob path' do
        secret_detection_finding_sha = finding.location.dig("commit", "sha")

        expect(subject).to eq(
          "/#{vulnerability.project.full_path}/-/blob/#{secret_detection_finding_sha}/#{vulnerability.finding.file}"
        )
      end
    end

    context 'with a Secret Detection finding made in "no git" mode' do
      # In a "no git" scan, the Secret Detection analyser populates a dummy value in the
      # finding's commit.sha property. We make sure the dummy value is not used to render
      # the blob link.
      let(:finding) { create(:vulnerabilities_finding, :with_secret_detection_in_no_git_mode, :with_pipeline) }

      it 'uses the latest SHA of the current branch in blob path' do
        expect(subject).to eq(
          "/#{vulnerability.project.full_path}/-/blob/#{finding.last_finding_pipeline.sha}/#{vulnerability.finding.file}"
        )
      end
    end
  end

  describe '.with_findings_by_uuid' do
    let_it_be(:vulnerability) { create(:vulnerability) }

    let(:uuid) { [SecureRandom.uuid] }

    subject { described_class.with_findings_by_uuid(uuid) }

    it { is_expected.to be_empty }

    context 'with findings' do
      let_it_be(:finding) { create(:vulnerabilities_finding, vulnerability: vulnerability) }

      it { is_expected.to be_empty }

      context 'with matching uuid' do
        let(:uuid) { [finding.uuid] }

        it { is_expected.to contain_exactly(vulnerability) }
      end
    end
  end

  describe '.with_findings_by_uuid_and_state scope' do
    let_it_be(:vulnerability) { create(:vulnerability, state: :detected) }

    let(:uuid) { ["592d0922-232a-470b-84e9-5ce1c7aa9477"] }

    subject { described_class.with_findings_by_uuid_and_state(uuid, ["detected"]) }

    it { is_expected.to be_empty }

    context 'with findings' do
      let_it_be(:finding) { create(:vulnerabilities_finding, vulnerability: vulnerability) }

      it { is_expected.to be_empty }

      context 'with matching uuid' do
        let(:uuid) { [finding.uuid] }

        it { is_expected.to contain_exactly(vulnerability) }
      end
    end
  end

  describe '.with_findings_excluding_uuid scope' do
    let_it_be(:vulnerability) { create(:vulnerability, :with_finding) }

    let(:uuid) { vulnerability.finding.uuid }

    subject { described_class.with_findings_excluding_uuid(uuid) }

    it { is_expected.not_to include(vulnerability) }

    context 'with mismatching uuid' do
      let(:uuid) { [SecureRandom.uuid] }

      it { is_expected.to include(vulnerability) }
    end
  end

  describe '.for_default_branch scope' do
    let_it_be(:vulnerability) { create(:vulnerability, present_on_default_branch: false) }

    subject { described_class.for_default_branch }

    it { is_expected.not_to include(vulnerability) }

    context 'with present_on_default_branch false' do
      subject { described_class.for_default_branch(false) }

      it { is_expected.to include(vulnerability) }
    end
  end

  describe '.with_keyset_order' do
    let(:unknown_sort_direction) { :abc }

    it 'raises an error when an unknown sort direction given' do
      expect do
        described_class.with_keyset_order(Vulnerability.state_order, 'state_order', unknown_sort_direction)
      end.to raise_error("unknown sort direction given: abc")
    end

    it 'raises an error when an unknown sort direction given for tie breaking column' do
      expect do
        described_class.with_keyset_order(Vulnerability.state_order, 'state_order', :asc, unknown_sort_direction)
      end.to raise_error("unknown tie breaker sort direction given: abc")
    end
  end

  describe '.present_on_default_branch' do
    let_it_be(:not_present) { create(:vulnerability, present_on_default_branch: false) }

    subject { described_class.present_on_default_branch }

    it 'does not return vulnerabilities which are not present on the default branch' do
      expect(subject).not_to include(not_present)
    end
  end

  describe '#notes_summary' do
    let(:vulnerability) { create(:vulnerability, project: project) }
    let!(:note) { create(:note, project: project, noteable: vulnerability, noteable_type: 'Vulnerability', note: 'changed vulnerabilities from detected to confirmed', author: user) }

    subject(:notes_summary) { vulnerability.notes_summary }

    it 'returns summarized version of notes' do
      expect(notes_summary).to eq("#{note.last_edited_at}|#{user.username}|confirmed|changed vulnerabilities from detected to confirmed\n")
    end

    context 'with multiple notes' do
      let!(:additional_note) { create(:note, project: project, noteable: vulnerability, noteable_type: 'Vulnerability', note: 'additional comment by the user', author: user, discussion_id: note.discussion_id) }

      it 'returns summarized version of notes' do
        expect(notes_summary).to eq("#{note.last_edited_at}|#{user.username}|confirmed|changed vulnerabilities from detected to confirmed\n; #{additional_note.last_edited_at}|#{user.username}|confirmed|additional comment by the user\n")
      end
    end
  end

  describe '#latest_state_transition' do
    context 'without any state transition' do
      it 'retuns nil' do
        expect(vulnerability.latest_state_transition).to be_nil
      end
    end

    context 'with state transition' do
      let_it_be(:vulnerability) { create(:vulnerability, :with_state_transition, project: project) }

      it 'return the latest state transition' do
        state_transition = create(:vulnerability_state_transition, vulnerability: vulnerability)
        expect(vulnerability.latest_state_transition).to eq state_transition
      end
    end
  end

  describe '#send_to_ai?' do
    let_it_be_with_reload(:group) { create(:group) }
    let_it_be_with_reload(:project) { create(:project, group: group) }

    context 'when the vulnerabilty is from SAST and includes the required information' do
      let_it_be(:vulnerability) { create(:vulnerability, :with_finding, project: project) }

      it { expect(vulnerability).to be_send_to_ai }
    end

    context 'when the vulnerability is for a Secret Detection' do
      let_it_be(:vulnerability) { create(:vulnerability, :secret_detection, :with_finding, project: project) }

      it { expect(vulnerability).to be_send_to_ai }
    end

    context 'when the vulnerability does not include a file' do
      let_it_be(:vulnerability) { create(:vulnerability, :with_finding, project: project) }

      before do
        vulnerability.finding.location.delete('file')
      end

      it { expect(vulnerability).to be_send_to_ai }
    end

    context 'when the vulnerability does not include a start line' do
      let_it_be(:vulnerability) { create(:vulnerability, :with_finding, project: project) }

      before do
        vulnerability.finding.location.delete('start_line')
      end

      it { expect(vulnerability).to be_send_to_ai }
    end

    context 'when the vulnerability does not include a finding' do
      let_it_be(:vulnerability) { create(:vulnerability, project: project) }

      it { expect(vulnerability).not_to be_send_to_ai }
    end
  end

  it_behaves_like 'database events tracking batch 2' do
    let(:namespace) { project.namespace }
    let(:record) { create(:vulnerability, :sast, :confirmed, :low, project: project) }
    let(:update_params) { { state: 'dismissed' } }
  end
end
